Frida 综合情景案例分析
前面几篇教程我们搭建了 Frida 开发环境、掌握了构造数组和对象参数的技巧。本文将进入实战环节,通过 5 个典型案例 覆盖 Frida 逆向中最常见的场景:登录拦截、SSL Pinning 绕过、返回值篡改、密钥替换和 JNI Hook。每个案例都会给出可复用的完整脚本。
案例1:Hook 登录接口获取用户名密码
分析目标 APP 的登录流程
在逆向一个 APP 时,登录接口往往是第一个需要关注的点。用户输入的账号密码在提交到服务器之前,通常会经过本地加密或编码处理。我们的目标是在加密之前拦截明文参数。
大多数 Android APP 的登录流程如下:
用户输入 → UI 层收集 → 加密/签名处理 → 网络请求发送
定位关键类和方法
假设目标 APP 使用了 Retrofit + OkHttp 进行网络请求,登录接口位于 com.example.app.api.UserService 类的 login 方法中:
public class UserService {
public Response login(String username, String password) {
// 内部会对 password 做 MD5 加密后发送
String encryptedPwd = MD5Util.encrypt(password);
return apiService.login(username, encryptedPwd);
}
}
编写 Frida 脚本拦截参数和返回值
// hook_login.js - Hook 登录接口获取明文用户名密码
Java.perform(function () {
var UserService = Java.use("com.example.app.api.UserService");
// Hook login 方法
UserService.login.implementation = function (username, password) {
console.log("[*] ========== 登录接口拦截 ==========");
console.log("[+] 用户名: " + username);
console.log("[+] 密码: " + password);
console.log("[+] 调用栈:");
console.log(Java.use("android.util.Log").getStackTraceString(
Java.use("java.lang.Exception").$new()
));
// 调用原方法并拦截返回值
var result = this.login(username, password);
console.log("[+] 返回值: " + result.toString());
// 如果想修改密码,可以替换参数
// var result = this.login(username, "my_modified_password");
return result;
};
});
进阶技巧:如果登录方法使用了重载,可以通过 overload 指定具体签名:
UserService.login.overload('java.lang.String', 'java.lang.String')
.implementation = function (username, password) {
// ...
};
如果类名不确定,可以先枚举已加载的类进行模糊搜索:
Java.enumerateLoadedClasses({
onMatch: function (className) {
if (className.indexOf("Login") !== -1 ||
className.indexOf("login") !== -1) {
console.log("[*] 发现可疑类: " + className);
}
},
onComplete: function () {
console.log("[*] 枚举完成");
}
});
案例2:绕过 SSL Pinning 实现抓包
SSL Pinning 原理简介
SSL Pinning(证书绑定)是一种安全机制,APP 在建立 HTTPS 连接时会校验服务器证书是否与本地内置的证书指纹匹配。即使你安装了 Charles/mitmproxy 的 CA 证书,APP 也会因为指纹不匹配而拒绝连接,导致抓包失败。
常见实现方式有两种:
- TrustManager 校验:自定义
X509TrustManager,在checkServerTrusted中比对证书 - OkHttp CertificatePinner:通过 OkHttp 的
certificatePinner设置 hostname 与证书指纹的映射
Hook TrustManager 和 OkHttp 的 certificatePinner
// hook_ssl_pinning.js - 通用 SSL Pinning 绕过脚本
Java.perform(function () {
// ====== 方案1:Hook TrustManager ======
console.log("[*] 开始 Hook TrustManager...");
var TrustManagerBuilder = Java.use("javax.net.ssl.TrustManagerBuilder");
try {
TrustManagerBuilder.checkServerTrusted.overload(
'[Ljava.security.cert.X509Certificate;', 'java.lang.String'
).implementation = function (chain, authType) {
console.log("[+] TrustManager.checkServerTrusted 被调用,已绕过");
};
} catch (e) {
console.log("[-] TrustManagerBuilder 不存在,尝试其他方式");
}
// Hook X509TrustManager 的所有实现
var X509TrustManager = Java.use("javax.net.ssl.X509TrustManager");
var SSLContext = Java.use("javax.net.ssl.SSLContext");
// 创建一个"信任一切"的 TrustManager
var TrustManager = Java.registerClass({
name: "com.frida.TrustAllManager",
implements: [X509TrustManager],
methods: {
checkClientTrusted: function (chain, authType) { },
checkServerTrusted: function (chain, authType) {
console.log("[+] checkServerTrusted 已绕过, authType=" + authType);
},
getAcceptedIssuers: function () {
return [];
}
}
});
// 替换所有 SSLContext 中的 TrustManager
var trustManagers = [TrustManager.$new()];
SSLContext.getInstance("TLS").init(null, trustManagers, null);
console.log("[+] SSLContext TrustManager 替换完成");
// ====== 方案2:Hook OkHttp CertificatePinner ======
console.log("[*] 开始 Hook OkHttp CertificatePinner...");
try {
var CertificatePinner = Java.use(
"okhttp3.CertificatePinner"
);
CertificatePinner.check.overload(
'java.lang.String', 'java.util.List'
).implementation = function (hostname, peerCertificates) {
console.log("[+] CertificatePinner.check 被调用");
console.log(" hostname: " + hostname);
console.log("[+] 已绕过 SSL Pinning 校验");
// 不调用原方法,直接返回,跳过校验
};
// Hook 较新版本的 OkHttp (4.x) 可能使用的方法签名
try {
CertificatePinner.check.overload(
'java.lang.String', 'kotlin.jvm.functions.Function0'
).implementation = function (hostname, peek) {
console.log("[+] CertificatePinner.check(4.x) 已绕过, hostname=" + hostname);
};
} catch (e) {
// 不是 4.x 版本,忽略
}
} catch (e) {
console.log("[-] OkHttp CertificatePinner 不存在: " + e);
}
// ====== 方案3:Hook WebViewClient 的 SSL 错误处理 ======
console.log("[*] 开始 Hook WebViewClient...");
try {
var WebViewClient = Java.use("android.webkit.WebViewClient");
WebViewClient.onReceivedSslError.implementation = function (
view, handler, error
) {
console.log("[+] WebView SSL 错误被忽略: " + error.toString());
handler.proceed(); // 继续加载
};
} catch (e) {
console.log("[-] WebViewClient hook 失败: " + e);
}
console.log("[*] SSL Pinning 绕过脚本加载完成");
});
提示:Frida 官方仓库提供了一个更完善的通用脚本 ssl-pinning-bypass,覆盖了更多框架的 Pinning 实现,建议在实际项目中优先使用。
案例3:修改函数返回值绕过验证
Hook isVip()、isRooted() 等检测函数
很多 APP 内部通过检查方法返回值来控制功能权限,例如 VIP 功能、Root 检测、设备绑定等。通过 Hook 修改返回值,可以快速绕过这些限制。
// hook_return_value.js - 修改函数返回值绕过验证
Java.perform(function () {
// ====== 绕过 VIP 检测 ======
try {
var UserInfo = Java.use("com.example.app.model.UserInfo");
UserInfo.isVip.implementation = function () {
console.log("[+] isVip() 被调用,原始返回: " + this.isVip());
console.log("[+] 修改返回值为 true");
return true;
};
UserInfo.getVipLevel.implementation = function () {
console.log("[+] getVipLevel() 被调用,修改为最高级 3");
return 3;
};
} catch (e) {
console.log("[-] VIP 检测类未找到: " + e);
}
// ====== 绕过 Root 检测 ======
try {
var RootCheck = Java.use("com.example.app.security.RootDetector");
RootCheck.isRooted.implementation = function () {
console.log("[+] isRooted() 返回 false,Root 检测已绕过");
return false;
};
} catch (e) {
console.log("[-] RootDetector 类未找到: " + e);
}
// ====== 绕过设备绑定检查 ======
try {
var DeviceBind = Java.use("com.example.app.security.DeviceBind");
DeviceBind.isDeviceBound.implementation = function () {
console.log("[+] isDeviceBound() 返回 true,设备绑定检查已绕过");
return true;
};
// 如果校验的是设备 ID 字符串
DeviceBind.getDeviceId.implementation = function () {
console.log("[+] getDeviceId() 被调用");
var original = this.getDeviceId();
console.log("[+] 原始设备ID: " + original);
var fakeId = "ANDROID_FAKE_DEVICE_001";
console.log("[+] 替换为: " + fakeId);
return fakeId;
};
} catch (e) {
console.log("[-] DeviceBind 类未找到: " + e);
}
// ====== 绕过签名校验 ======
try {
var SignatureCheck = Java.use(
"com.example.app.security.SignatureVerify"
);
SignatureCheck.verifySignature.implementation = function () {
console.log("[+] 签名校验已绕过");
return true;
};
} catch (e) {
console.log("[-] SignatureVerify 类未找到: " + e);
}
console.log("[*] 返回值修改脚本加载完成");
});
注意事项:有些检测函数可能在多个类中都有实现,建议先用搜索定位所有相关调用点,逐一 Hook。
案例4:动态修改加密算法的密钥
Hook 密钥生成函数
在逆向分析中,经常遇到 APP 使用固定密钥对数据进行 AES/DES 加密。如果密钥是动态生成的,我们可以通过 Hook 密钥生成过程来获取或替换密钥。
// hook_crypto_key.js - 动态修改加密密钥
Java.perform(function () {
// ====== Hook javax.crypto.Cipher ======
var Cipher = Java.use("javax.crypto.Cipher");
// Hook Cipher.init —— 捕获密钥
Cipher.init.overload(
'int', 'java.security.Key',
'java.security.spec.AlgorithmParameterSpec'
).implementation = function (opmode, key, params) {
var algo = this.getAlgorithm();
var keyBytes = key.getEncoded();
var keyHex = bytesToHex(keyBytes);
console.log("[*] ========== Cipher.init ==========");
console.log("[+] 算法: " + algo);
console.log("[+] 模式: " + (opmode == 1 ? "加密" : "解密"));
console.log("[+] 原始密钥(hex): " + keyHex);
// 替换为我们自己的密钥(16字节 AES-128)
if (keyBytes.length === 16) {
var newKeyBytes = [0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48,
0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50];
var SecretKeySpec = Java.use("javax.crypto.spec.SecretKeySpec");
var newKey = SecretKeySpec.$new(
arrayToByte(newKeyBytes), key.getAlgorithm()
);
console.log("[+] 替换密钥(hex): " + bytesToHex(newKey.getEncoded()));
this.init(opmode, newKey, params);
} else {
this.init(opmode, key, params);
}
};
// Hook Cipher.doFinal —— 捕获输入输出
Cipher.doFinal.overload('[B').implementation = function (input) {
var inputHex = bytesToHex(input);
console.log("[+] doFinal 输入(hex): " + inputHex.substring(0, 64) + "...");
var result = this.doFinal(input);
var outputHex = bytesToHex(result);
console.log("[+] doFinal 输出(hex): " + outputHex.substring(0, 64) + "...");
return result;
};
// ====== Hook SecretKeySpec 构造函数 ======
try {
var SecretKeySpec = Java.use("javax.crypto.spec.SecretKeySpec");
SecretKeySpec.$init.overload('[B', 'java.lang.String')
.implementation = function (keyBytes, algo) {
console.log("[*] ========== SecretKeySpec 创建 ==========");
console.log("[+] 算法: " + algo);
console.log("[+] 密钥(hex): " + bytesToHex(keyBytes));
console.log("[+] 密钥长度: " + keyBytes.length + " bytes");
this.$init(keyBytes, algo);
};
} catch (e) {
console.log("[-] SecretKeySpec hook 失败: " + e);
}
console.log("[*] 加密密钥 Hook 脚本加载完成");
// ====== 工具函数 ======
function bytesToHex(byteArray) {
var hex = "";
for (var i = 0; i < byteArray.length; i++) {
var b = (byteArray[i] & 0xFF).toString(16);
hex += (b.length === 1 ? "0" + b : b);
}
return hex;
}
function arrayToByte(arr) {
var ByteArray = Java.array('byte', arr);
return ByteArray;
}
});
核心原理:通过 Hook
Cipher.init和SecretKeySpec的构造函数,我们可以在密钥生成/使用的瞬间截获原始密钥,并将其替换为我们控制的密钥。这样就能用已知密钥解密所有通信数据。
案例5:Hook JNI 函数获取 native 层数据
使用 Interceptor.attach hook SO 中的导出函数
当核心逻辑被放进了 Native 层(SO 库),Java 层的 Hook 就无能为力了。这时需要使用 Frida 的 Interceptor 模块直接 Hook SO 中的导出函数。
// hook_native.js - Hook JNI 函数获取 native 层数据
// ====== 基础示例:Hook SO 导出函数 ======
Interceptor.attach(Module.findExportByName("libnative-lib.so", "Java_com_example_app_NativeHelper_encrypt"), {
onEnter: function (args) {
// JNI 函数签名: JNIEnv*, jobject, jstring
console.log("[*] ========== native encrypt 被调用 ==========");
// args[0] = JNIEnv*
// args[1] = jobject (this)
// args[2] = jstring (输入字符串)
// 读取 JNI 参数需要通过 JNIEnv 的函数表
// 这里我们直接读取内存中的字符串数据
var env = args[0];
var jstr = args[2];
// 通过 JNI 调用 GetStringUTFChars
var GetStringUTFChars = new NativeFunction(
env.readPointer().add(0x548).readPointer(),
'pointer', ['pointer', 'pointer', 'pointer']
);
var cstr = GetStringUTFChars(env, jstr, ptr(0));
console.log("[+] 输入字符串: " + cstr.readUtf8String());
},
onLeave: function (retval) {
// retval 是 jstring 返回值
console.log("[+] 返回值(jstring): " + retval);
}
});
// ====== 进阶:Hook 非 JNI 的普通 C 函数 ======
Interceptor.attach(Module.findExportByName("libnative-lib.so", "aes_encrypt"), {
onEnter: function (args) {
console.log("[*] ========== aes_encrypt 被调用 ==========");
// 假设函数签名: int aes_encrypt(uint8_t* input, int input_len,
// uint8_t* output, uint8_t* key)
var input = args[0];
var inputLen = args[1].toInt32();
var output = args[2];
var key = args[3];
console.log("[+] 输入数据(hex): " + hexdump(input, { length: inputLen }));
console.log("[+] 输入长度: " + inputLen);
// 读取密钥(假设 AES-128 = 16 字节)
if (!key.isNull()) {
console.log("[+] 密钥(hex): " + hexdump(key, { length: 16 }));
}
},
onLeave: function (retval) {
// retval 是 int 返回值
console.log("[+] 返回值: " + retval.toInt32());
}
});
读写寄存器和内存
// hook_registers_memory.js - 读写 ARM 寄存器和内存
// Hook 一个函数并修改寄存器值
Interceptor.attach(Module.findExportByName("libnative-lib.so", "check_license"), {
onEnter: function (args) {
console.log("[*] ========== check_license 进入 ==========");
console.log("[+] x0 (JNIEnv*): " + this.context.x0);
console.log("[+] x1 (jobject): " + this.context.x1);
console.log("[+] PC: " + this.context.pc);
// 修改参数寄存器的值(ARM64 下参数通过 x0-x7 传递)
// this.context.x2 = ptr(0x1); // 修改第三个参数为 1
},
onLeave: function (retval) {
console.log("[*] ========== check_license 返回 ==========");
console.log("[+] 返回值(x0): " + this.context.x0);
// 修改返回值为 1(表示验证通过)
this.context.x0 = ptr(1);
console.log("[+] 已将返回值修改为 1");
}
});
// ====== 内存读写操作 ======
// 读取指定地址的内存
function readMemory(address, size) {
try {
var buf = Memory.readByteArray(address, size);
console.log(hexdump(buf, { header: false, ansi: false }));
} catch (e) {
console.log("[-] 内存读取失败: " + e);
}
}
// 在内存中搜索特征字符串
function searchPattern(moduleName, pattern) {
var ranges = Process.enumerateRangesSync('r--');
ranges.forEach(function (range) {
try {
var results = Memory.scanSync(range.base, range.size, pattern);
results.forEach(function (match) {
console.log("[+] 找到匹配: " + match.address + " (" + match.size + " bytes)");
});
} catch (e) {
// 忽略不可读区域
}
});
}
// 使用示例:搜索 SO 文件中的 AES S-Box 常量
console.log("[*] 搜索 AES S-Box...");
// AES S-Box 的前几个字节特征
searchPattern("libnative-lib.so", "63 7C 77 7B F2 6B 6F C5");
寄存器约定:在 ARM64 下,函数参数通过
x0-x7传递,返回值放在x0。在 ARM32(Thumb)下,参数通过r0-r3传递。Frida 通过this.context访问这些寄存器。
综合技巧总结
| 场景 | 核心 API | 关键要点 |
|---|---|---|
| Java 方法 Hook | Java.use().method.implementation |
注意重载使用 overload() |
| 枚举类 | Java.enumerateLoadedClasses() |
结合字符串匹配模糊定位 |
| SSL Pinning 绕过 | Hook TrustManager + CertificatePinner |
多方案组合确保成功率 |
| 返回值篡改 | 替换 implementation 返回值 | 注意多检测点需全部覆盖 |
| 加密密钥拦截 | Hook Cipher.init / SecretKeySpec |
修改密钥可实现自定义解密 |
| Native 函数 Hook | Interceptor.attach() |
理解 JNI 调用约定和寄存器 |
| 内存操作 | Memory.readByteArray() / Memory.scanSync() |
搜索特征常量定位关键函数 |
调试建议:
- 善用
hexdump():对二进制数据使用hexdump()输出,比直接打印可读性更好 - 调用栈追踪:Java 层用
Log.getStackTraceString(),Native 层用Thread.backtrace()配合DebugSymbol.fromAddress()解析符号名 - 定时器模式:对于启动时就执行的函数,可以使用
setTimeout延迟 Hook,或通过Java.scheduleOnMainThread()确保在主线程执行 - Spawn 模式:使用
frida -U -f com.example.app -l script.js以 Spawn 模式启动,可以在 APP 加载前注入,避免错过早期初始化逻辑
以上就是 Frida 在 Android 逆向中常见的五大综合案例。掌握这些模式后,大部分逆向场景都可以通过组合和变体来应对。下一阶段建议深入 Frida 的 RPC 机制和 Stalker 代码追踪,进一步提升逆向分析能力。